怎样利用 git 撤销操作

前言

    对于版本管理系统而言,最重要的一个特性之一就是能够撤销错误的操作。在 git 中,撤销有很多种可能的意思。
    每当你做出一个 提交 的时候,git 都会立即在你的 repository 保存一个快照,之后你就可以通过 git 来恢复到这个项目的前一个版本了。
    这下面这篇文章中,我将会就几种常见的情景来讨论怎样通过 git 来完美的撤销这些操作。

不同情况

撤销已发布

情景:你刚按下 git push ,将修改推送到了 github ,然后就发现在你的提交中存在一些问题。这时候那就会想撤销操作。

解决git revert <SHA>

原理git revert <SHA> 这个操作会生成一个新的 commit,这个提交和 提供的 SHA 是相反的:如果之前的提交是 “matter”,那么这个提交就是 “anti-matter”,也就是说之前操作中新增的内容将会被删除,之前操作中被删除的内容也将会被增加。

这是 git 中最基础也最安全的撤销操作情景,因为它不会 改动 变更历史——你可以放心的使用 git push “相反的”提交来撤销你错误的操作。


修改提交信息

情景:你把提交信息的最后一个字打完:git commit -m "Fxies bug #42,提交之后推送之前,你发现你的提交信息出错了,应该是:Fixes bug #42。这时候你也会想到撤销操作。

解决git commit --amend or git commit --amend -m "Fixes bug #42"

原理git commit --amend 操作会用一个新的提交来更新、替换上一个提交。如果新提交的内容中有已处于可提交状态的修改,那么也会一并提交;如果没有,那么只需要重写上一次的提交信息即可。


撤销本地操作

情景:一只喵从键盘上走过,不小心保存了修改,然后很巧的是 IDE 此时崩溃退出了。这时候你还没有提交,你希望能够将这个文件中所有的修改都撤销——直到恢复成上一次提交的状态。

解决git checkout -- <bad filename>

原理git checkout 指令会将工作文件夹中的这个文件恢复成在 Git 中上一次保存的状态。你可以提供一个确切的分支名,或者是需要恢复到的 SHA。如果都省略,Git 就默认是恢复到 HEAD ——也就是目前分支的上一次提交状态。

注意:利用这种方法所做的任何撤销都是真的撤销了。因为没有提交操作,所以 Git 之后也没办法来恢复了。用这种方法的时候一定要确定你所需要撤销的到底是什么!(你可以利用 git diff 来确认提交信息)


重置本地操作

情景:你在本地提交过几次(但还没有推送),然后你发现这些都是垃圾,然后你想撤销最近三次的提交——就好像它们从来没发生过一样。

解决git reset <last good SHA> or git reset --hard <last good SHA>

原理git reset 操作会让你的项目历史记录回滚到指定的 SHA,就像这些提交从来没有发生过一样。默认状态下,这个操作会将工作目录保存下来,这时候提交虽然没有了,但是这些提交的内容还是存在硬盘上的。这是最安全的做法,但是很多时候,你需要同时撤销提交并同时删除这些文件——这时候就需要加上 --hard 了。


重置本地操作后恢复

情景:你已经提交了一些修改,然后发现不对,就利用上面的方法撤销了提交。这时候你又发现:我要撤销刚刚的撤销!

解决git reflog and git reset or git checkout

原理git reflog 是一种非常好的用于恢复项目历史记录的办法。通过这个办法,几乎可以恢复任何已经提交过的修改。

你可能对用于显示不同提交的 git log 指令比较熟悉,而 git reflog 也是类似的,区别在于,它显示的是 HEAD 的修改情况列表。

注意点:

  • 只有 .HEAD 文件被修改时 HEAD 才会修改,这些情况包括:切换分支、通过 git commit 提交、通过 git-reset 恢复。需要注意的是通过 git checkout --<bad filename> 操作并不会修改 HEAD(上面的方法中提到过这个操作不会被提交),所以 reflog 方法这时候不适用。
  • git reflog 不会被永久保存。Git 内部每隔一段时间就好清理一些无法识别的对象。所以不要指望几个月前的提交记录能够永远在 reflog 中找到。
  • 你的 reflog 仅仅只能你自己使用。你无法通过这个指令来恢复其他开发者尚未推送的提交。

那么,回到正题,怎样通过 git reflog 来达到撤销已撤销的目的呢?这还得根据不同的情况讨论:

  • 如果你想将项目历史恢复成特定时候的状态,使用 get reset -hard <SHA>
  • 如果你想在你的工作目录下重新创造一些和之前一样的文件,同时不更改项目记录,那么就用 git checkout <SHA> --<filename>
  • 如果你想重新提交一个确切的操作到你的项目,那就是用 git cherry-pick <SHA>

修改合并分支

情景:你提交了一些修改,然后发现提交到了 master 分支,而你本来是想提交到一个叫做 feature 的分支上。

解决git branch feature, git reset --hard origin/master, and git checkout feature

原理:你可能比较习惯使用 git checkout -b <name> 来建立并切换到新建立的分支上——然而,有时候你并不希望立刻切换到新建立的分支。通过 git branck feature 创建了一个叫做 feature 的分支,这也是你将要合并提交的分支,而且这样操作,目前所在的分支还在 master 上(并没有切换到 feature)。
然后,通过 git reset --hard 将 master 分支回滚到之前 origi/master 未提交的状态。不过不要担心,在 feature 分支上,这些提交还是有的。
最后,通过 git checkout 指令将分支切换至 feature,然后你就可以开心的进行提交了。


同步更新分支

情景:你在本地分支 master 的基础上新建了一个叫做 feature 的分支,但是这时候的本地分支 master 比远程主机上的 origin/master 分支落后几个版本。然后你将本地 master 分支同步至 origin/master,然后你希望这时候提交到 feature 分支上的修改能够在现在这个状态的基础上,而不是之前落后几个版本的状态。

解决git checkout feature and git rebase master

原理:你本来可以这样做:首先利用 git reset(后面故意不加 –hard,这样的话 master 虽然回滚到上个版本但是硬盘上这些可提交的修改并没有删除),然后通过 git checkout <branch name> 切换至 feature 分支,最后在这个分支上重新提交(以达到和之前 master 分支一样的状态)。通过这种办法可以达到目的,但是你会失去提交历史。下面有一个更好的方法。

get rebase master 操作会执行很多事情:

  • 首先在目前所在分支和本地 master 分支之间确定谁是谁的父级;
  • 然后将目前所在分支的状态重置成父级的状态,并把之后的提交都暂时保存在一个区域;
  • 最后更新升级到和 master 分支最新状态一样,然后重新操作暂时保存区域的提交。

大量的撤销/恢复操作

情景:你从某个方向来开始了一项研究,研究到一半,你发现另一种方法会更好。这时候你已经提交了几次了,而你其实并不完全需要它们。所以你希望其中的一些提交完全消失。

解决git rebase -i <earlier SHA>

原理:-i 指令将 rebase 指令转入一种可交互模式。rebase 指令的作用前几步和上面都一样,唯一不一样的是在合并提交的时候,它会暂停然后让你选择需要合并的提交。

rebase -i 会打开默认文本编辑器,文本中会列出可合并的提交,如下:

前面两列是包含着关键信息:第一个是用来提交操作的指令,第二个是提交操作的 SHA 的值。利用 rebase -i 合并操作的默认指令是 pick

对于不想要的提交,你只需要在文本编辑器中删除对应的内容。如上面这张图,如果你不想要错误的提交,那就删除1、3、4这三行。

如果你想保持提交的内容但是想修改提交的提交信息,那你可以使用 reword 命令。只需要在第一列中替代 pick。虽然立即就改写提交信息听起来很爽,但是实际上并没有用,因为 rebase -i 会将 SHA 后面的信息都无视掉。这个文本只是用来提醒我们这个提交的内容是什么,在执行完 rebase -i 命令之后,就可以改写任意的提交信息了。

如果你想将两个提交合并,你可以使用 squash 或者 fixup 指令,如下:

这两个指令都会使得对应的提交和它之前的提交结合成一个提交。在上面的例子中,第一个和第二个会结合成一个提交,然后第三个和第四个也会结合成一个提交。

当使用 squash 时, Git 会让我们给新合并的提交一个新的提交信息;当使用 fixup 的时候,Git 会自动将列表中将合并的提交的最开始的提交信息作为新的提交信息。拿上图为例,前两个合并时需要输入一个新的合并提交的信息,而后两个合并时,会自动将第三个提交信息作为合并后的提交信息。

当你保存然后关闭文本编辑器的时候, Git就会依次执行编辑器中的内容。如果你希望更改合并的顺序,只需要在文本编辑器中改动。拿上图为例,如果你想合并 af67f820835fe2 ,你只需要这样更换顺序:


更改早先的提交

情景:你突然想起来,如果在之前的一次提交中加入某一个文件效果会更好。这时候你还没有推送这个提交,而这个提交并不是最近的一次,所以不能用 commit --amend

解决git commit --squash <SHA of the earlier commit> and git rebase --autosquash -i <even earlier SHA>

原理:利用 git commit --squash 可以创建一个新的提交并有类似这样的提交信息 squash! Earlier commit(你可以手动输入像这样的提交信息,但是使用 commit --squash 可以让你少打一些字 。)

你也可以使用 git commit --fixup 指令来操作,利用这个指令不需要手动输入提交信息。这上面这个情境中,利用这个指令更好一些,因为不需要更改提交信息。

利用 rebase --autosquash -i 指令,然后会打开默认的文本编辑器,而且里面已经包含了提交信息,如下图:

当你使用 squash 或者 fixup 指令的时候,你可能不知道你需要合并的提交的 SHA —— 你只记得是一次还是五次之前。这时候操作符 ^~ 就很方便了。HEAD^ 表示 HEAD 之前的一次, HEAD~4 表示的是 HEAD 的前四个提交,也就是倒数第五次提交(因为 HEAD 本身是倒数第一次)。


停止对已监测文件的监测

情景:你最近将 application.log 加入到了仓库中,然后现在每次运行这个程序, Git 就会提示在这个文件中有未处于提交状态的修改。虽然你将 *.log 写入了 .gitignore 文件,但是情况并没有变化。要怎么才能让 git 放弃监测这个文件的变化呢?

解决git rm --cached application.log

原理.gitignore 会使得 Git不去监测文件的变化,对于之前没有检测过的文件,它甚至会无视文件的存在。只要文件被提交了, Git 就会持续监测它的变化。类似的,如果利用 git add -f 或者覆盖 .gitignore 文件,那么 Git 也会监测这些文件的变化。

如果你想使得那些本不应该被监测的文件脱离监测,那么你可以使用 git rm --cached 指令。利用这个指令会停止 Git 对它的监测,同时这个文件在硬盘上不受影响。由于这个文件现在不被监测了,所以利用 git status 不能看到这个文件了。


总结

    这是一篇译文,原文地址为: How to undo (almost) anything with Git。由于自己碰到了这方面的问题,然后搜索过程中发现了这片文章,写的很全面,所以就翻译了一下。翻译的过程中可能会有一些理解上的偏差,欢迎盆友们指正。

commit: 在文中被翻译为 提交
push: 在文中被译为 推送

赠人玫瑰,手有余香